Wrapping websites in WebViews using SwiftUI

What’s the difference between a web app and a native app on macOS? The most obvious one: apps can sit in the dock with a nice big app icon and we can easily switch between apps at any time using ⌘ + TAB. On the other hand, websites and web apps are always further away: you need to open the browser first, find the right tab or enter the URL.

What if we could turn any website into an app? Well, with SwiftUI and WebViews it’s possible to create a transparent wrapper app that displays a website. The best part: all the browser chrome will be hidden. All you see is the website. Nothing else. In this article I’ll explain how to wrap websites in WebViews. If you want to do the same, you need to have Xcode installed.

My goal was to create a wrapper for the Google Material Icons website. I’ve been using these icons so often for prototypes, that I typed the URL over and over again. Now I have an app icon sitting in my dock instead. In the future I may use this approach to wrap my own custom web apps. This might be an alternative to using heavy wrappers, such as Electron, if you only care about native macOS apps.

I haven’t fully tested this. I imagine issues once you want to use this approach for sites that require cookies, sessions, and other things. But for simple websites, this works.

  1. Create a new Xcode project. Choose macOS, then App. Make sure the interface is SwiftUI.
  1. Enter a name for the project and save it somewhere.
  1. Insert the following code into the existing ContentView.swift:
import SwiftUI

@available(OSX 11.0, *)

struct ContentView: View {

  private var url: URL? = URL(string: "https://fonts.google.com/icons?selected=Material+Icons")

  init() {
    print("Hello World")
  }

  var body: some View {
    WebView(data: WebViewData(url: self.url!))
  }
}

No worries, you will see some errors. We’ll fix them soon.

  1. Create a new Swift file. Enter WebView.swift as a name and save it.
  1. Paste the following code. Again, you’ll see errors that you can ignore for now.
import SwiftUI
import WebKit
import Combine

class WebViewData: ObservableObject {
  @Published var loading: Bool = false
  @Published var url: URL?;

  init (url: URL) {
    self.url = url
  }
}

@available(OSX 11.0, *)
struct WebView: NSViewRepresentable {
  @ObservedObject var data: WebViewData

  func makeNSView(context: Context) -> WKWebView {
    return context.coordinator.webView
  }

  func updateNSView(_ nsView: WKWebView, context: Context) {

    guard context.coordinator.loadedUrl != data.url else { return }
    context.coordinator.loadedUrl = data.url

    if let url = data.url {
      DispatchQueue.main.async {
        let request = URLRequest(url: url)
        nsView.load(request)
      }
    }

    context.coordinator.data.url = data.url
  }

  func makeCoordinator() -> WebViewCoordinator {
    return WebViewCoordinator(data: data)
  }
}

@available(OSX 11.0, *)
class WebViewCoordinator: NSObject, WKNavigationDelegate {
  @ObservedObject var data: WebViewData

  var webView: WKWebView = WKWebView()
  var loadedUrl: URL? = nil

  init(data: WebViewData) {
    self.data = data

    super.init()

    webView.navigationDelegate = self
  }

  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    DispatchQueue.main.async {
      self.data.loading = false
    }
  }

  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
    DispatchQueue.main.async { self.data.loading = true }
  }

  func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
    showError(title: "Navigation Error", message: error.localizedDescription)
    DispatchQueue.main.async { self.data.loading = false }
  }

  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
    showError(title: "Loading Error", message: error.localizedDescription)
    DispatchQueue.main.async { self.data.loading = false }
  }


  func showError(title: String, message: String) {
    #if os(macOS)
    let alert: NSAlert = NSAlert()

    alert.messageText = title
    alert.informativeText = message
    alert.alertStyle = .warning

    alert.runModal()
    #else
    print("\(title): \(message)")
    #endif
  }
}
  1. Select the project in the sidebar (top most entry). Select “Signing & Capabilities“, then enable the two options listed under Network. (Incoming and Outcoming Connections)
  1. Switch to the AppDelegate.swift file. The compiler will complain that ContentView is only available in macOS 11.0 or newer. I honestly don't know what the best option to fix it is. I went with the option Add @available attribute to enclosing class.
  1. All errors are fixed. Ready to build!
  2. Press the play button or choose Product / Build from the menu bar.

Here’s the result: the Google Material Design Icons website wrapped in a WebView, launched from my dock.

If you want to go one step further and save the app for your own use, then choose Product / Archive. Click Distribute App, then choose Copy App.


Update: It’s also possible to load local websites that are stored within the project folder.

import SwiftUI

@available(OSX 11.0, *)

struct ContentView: View {

  func bundleURL(fileName: String, fileExtension: String) -> URL {
    if let fileURL = Bundle.main.url(forResource: fileName, withExtension: fileExtension, subdirectory: "www") {
      return fileURL
    } else {
      print("File not found")
      return URL(string: "")!
    }
  }

  init() {
    print("Hello World")
  }

  var body: some View {
    WebView(data: WebViewData(url: self.bundleURL(fileName: "index", fileExtension: "html")))
  }
}